字符串:KMP 入门
KMP 类算法主要围绕 border 这一概念展开,并根据 border 的性质、引理等设计算法。
虽然说理解 KMP 不一定要会 border,但是我觉得掌握 border 之后可以对 KMP 算法进行扩展,解决更多问题。
本文中字符串的索引从 0 开始。 因为 std::string
下表是从 0 开始的。
什么是 Border
Border 直译就是边框。对于一个字符串 ,其 border 是指这样一个子串 ,其既是 的前缀,又是 的后缀,比如:
上图中框起来的就是 的第一个 border。border不一定是极大的,比如: 这也是一个 border。border 也可以是重叠的前后缀。
我们可以得到 border 的两个小性质:
-
如果 都是 的 border,且 ,那么 是 的 border。以下是一个例子:
上图中 、 都是整个字符串的 border,显然也满足 是 的 border。
-
对于一个串 ,其所有 border 构成的集合记为 ,设 是 最大的 border,那么有:
这就是说,我们可以递归地求出 的 border 集合。
基础 KMP 算法
KMP 算法做这样一件事:给定两个串 、,求 是否是 的一个子串。其时间复杂度在任何情况下都是 ,其中 分别是串 的长度。这样的问题也叫字符串的模式匹配。
KMP 的思想是避免回溯。让我们看下面的例子:
我们做模式匹配时,会用会用两个指针:,分别遍历 。如图 ,。每次我们发现 时,就让 ,。
如上图所示,我们匹配了前 4 个字符,当匹配到第 5 个字符时,发生了失配。对于朴素的匹配算法,我们可能会考虑让 返回当前匹配段的起点,往后移一个位置,同时让 回到 0。也就是尝试在以下位置继续匹配:
但是这样效率太慢了,相当于我们之前匹配的串的信息被浪费了。KMP 的思想是:让 永不回溯。那怎么办呢?结合我们前面对 border 的铺垫,可以发现, 这段匹配的串,其有一个 border 。所以我们可以这样做:
这样我们没有移动 ,而是让 往前移。利用匹配串存在 border,或者说存在相同的前后缀这一信息,我们可以减少很多比较次数。注意和上面的 border 略有不同,我们这里希望找到最大的 border,即最长公共前后缀。
怎么维护最长公共前后缀呢?
next 数组
我们引入 数组: 表示串 中 子段中最长公共前后缀的长度,也就是前 个字符组成的子串的最长公共前后缀长度。
所以我们要想知道 最长公共前后缀的长度,自然就是访问 咯。
注意: 这意味着 数组有效的范围是 。
我们要在做模式匹配之前预处理 数组,考虑通过递推求 数组:
-
初始条件:,从 开始循环,当 时退出循环。
-
假设我们刚刚求好了 ,现在求 。首先我们让 ,这样我们就让 指向了 子段的最长公共前缀的下一个字符,如果这个字符和 匹配,就可以组成 ,也就是 的新 · 最大公共前后缀。
-
如果 ,意味着公共前后缀可以扩展,所以令 。
-
如果 ,那么我们要让 往前移,缩小范围。怎么知道往前移多少呢?我们知道以 结尾的公共前后串长度为 。所以我们令 ,尝试判断 ,如果等式不成立,我们接着跳。
-
当然也得有个边界条件,那就是当 时停止。
-
不管什么时候停止跳跃,我们都检测一下 ,如果成立就令 ,否则 。
-
-
代码:
int nxt[MAXN + 1];
void initNxt(const string& p) {
int i = 0;
for (int k = 1; k < p.size(); k++) {
i = nxt[k];
if (p[i] == p[k]) {
nxt[k + 1] = nxt[k] + 1;
} else {
while (i and p[i] != p[k]) i = nxt[i];
if (p[i] == p[k]) nxt[k + 1] = i + 1;
else nxt[k + 1] = 0;
}
}
}
为了减少码量也可以这样写,是等价的:
void initNxt(const string& p) {
int i = 0;
for (int k = 1; k < p.size(); k++) {
i = nxt[k];
while (i and p[i] != p[k]) i = nxt[i];
if (p[i] == p[k]) nxt[k + 1] = i + 1;
else nxt[k + 1] = 0;
}
}
KMP 本体
刚才 数组的计算应该是最复杂的部分了。处理完 ,做 KMP 就如履平地了,直接看代码吧:
void kmp(const string& s, const string& p) {
int j = 0;
for (int i = 0; i < s.size(); i++) {
// 当前失配,尝试跳跃
while (j and s[i] != p[j]) {
j = nxt[j];
}
// 当前匹配
if (s[i] == p[j]) {
if (++j == p.size()) { // 到头了
cout << i - p.size() + 2 << '\n'; // 输出答案;题目的字符串索引是从 1 开始的
}
}
}
}
唉,好像也不是很简单。其实是有很多妙处的,以后再记吧。